主辦單位傳來一張活動海報,時間、地點散在各個角落,LINE 小幫手要怎麼查?今天用 Gemini 看圖,把視覺資訊整理成固定欄位,經過人工核對後接回昨天的搜尋工具。像是「活動 7 點半開始、那幾點集合」這種差別,模型分得清楚嗎?我們拿彰化縣政府「卦山大縱走」的真實海報,換兩種提示來看看。
Day 6 turns a real event poster into searchable LOCAL data using Gemini image understanding and structured outputs. We compare two prompts on the same image, inspect field-level quotations, review the extracted values, and reuse Day 5’s Python search tool. The example separates event details from meeting instructions and preserves missing information for later clarification.

圖 1:左側為彰化縣政府「卦山大縱走」活動海報,右側對照一般提示(產生 Schema Error)與欄位提示(成功擷取 8 場活動與引句)。回到原圖核對標籤,就能確認資訊是否放進了正確欄位。
| 本次留下的成果 | 紀錄 |
|---|---|
| 海報來源 | https://www.chcg.gov.tw/ch2/newsdetail.aspx?bull_id=436513 |
| 同圖提示比較 | 一般提示:SCHEMA_ERROR;欄位提示:EXTRACTED |
| 人工核對後的活動/場次 | 8 筆(整理出卦山大縱走全部 8 場步道) |
| 沿用 Day 5 工具查詢 | 查到 8 筆;查詢階段 API 呼叫 0 次(本機 Python 工具) |
想像主辦單位傳來一張海報,活動名稱很醒目,日期在中間,集合地點卻縮在右下角。對人來說,在手機上放大一點就看得懂;要讓 LOCAL 回答問題,還得把這些資訊放進程式能讀的欄位。
昨天的 LOCAL 已經能透過 search_local_events 查四筆活動,還把集合資訊送回 LINE。今天換個做法:海報交給 Gemini 先整理,我們對照原圖核對,再把資料交回同一個搜尋工具。
海報負責吸引人,資料負責讓人查。總不能主辦每改一次海報,我們就重新抄寫一次時間地點吧!中間這段「把視覺資訊搬到對的欄位」,今天交給 Gemini 先幫忙。
完整流程如下:
活動海報 → Gemini 圖片理解 → 結構化欄位 → 對照原圖核對 → 活動目錄 → Day 5 搜尋工具。
這次從管理資料的一端補上能力,既有 LINE 入口繼續保留。讀者先在電腦上跑通資料流程,就能看見同一張海報如何變成可查詢的內容。
看成果時,我把原圖、一般提示、欄位提示與人工採用值並排。兩組提示在同一次測試中各執行一次:
| 組別 | 執行狀態 | 擷取結果 | 呼叫耗時 | Token 總量 |
|---|---|---|---|---|
| 一般提示 | SCHEMA_ERROR |
欄位驗證未通過(已收到文字,但日期格式驗證失敗,原始回覆保留於紀錄) | 6.760 秒 | 4,124 |
| 欄位提示 | EXTRACTED |
成功擷取 8 場活動與原文引句,作為本次人工核對起點 | 7.616 秒 | 4,146 |
這次彰化縣政府的「卦山大縱走 彰化兜兜圈」海報涵蓋了 8 場步道活動,模型整理出每一條步道路線與對應的鄉鎮市區:
| 場次名稱 | 鄉鎮市區 | 場地/步道 | 圖片月日(quote) | 活動時段 | 集合時間/地點 | 全程輪椅通行 |
|---|---|---|---|---|---|---|
| 卦山大縱走 彰化兜兜圈 | 花壇 | 大嶺巷步道 | 09.19 (null) |
07:30~11:00 | null / null |
null |
| 卦山大縱走 彰化兜兜圈 | 二水 | 登廟步道 | 09.26 (null) |
07:30~11:00 | null / null |
null |
| 卦山大縱走 彰化兜兜圈 | 芬園 | 挑水古道 | 10.10 (null) |
07:30~11:00 | null / null |
null |
| 卦山大縱走 彰化兜兜圈 | 彰化市 | 桃源里森林步道 | 10.24 (null) |
07:30~11:00 | null / null |
null |
| 卦山大縱走 彰化兜兜圈 | 彰化市 | 大佛環山步道 | 10.31 (null) |
07:30~11:00 | null / null |
null |
| 卦山大縱走 彰化兜兜圈 | 田中 | 麒麟山步道 | 11.07 (null) |
07:30~11:00 | null / null |
null |
| 卦山大縱走 彰化兜兜圈 | 員林 | 藤山步道 | 11.14 (null) |
07:30~11:00 | null / null |
null |
| 卦山大縱走 彰化兜兜圈 | 社頭 | 十八彎及中央嶺步道 | 11.21 (null) |
07:30~11:00 | null / null |
null |
我逐項對照原圖後,採用這組八場資料,這次沒有修改欄位。八場的名稱、鄉鎮、步道與時段都和原圖一致;年份、集合時間/地點與通行條件海報本來就沒印,所以沒有可改的欄位。圖片只寫月日,完整日期仍缺年份;接下來會補上對應公告的依據。
我的判讀:
海報通常只寫活動時段與步道名稱,未特別註記集合點與無障礙,這跟真實世界完全一致。一般提示因為海報只印月日(如 09.19)缺少西元年份,模型直接填寫格式不符的字串而觸發本機 Schema 格式錯誤;欄位提示則能正確辨識缺漏,把值保留為 null 並在 quote 留下原文字串,更把卦山大縱走全部 8 場步道完整整理出來。
| 執行條件 | 本次紀錄 |
|---|---|
| 指定模型/思考等級 | gemini-3.8-flash/LOW |
| 回傳模型版本(一般/欄位) | gemini-3.8-flash/gemini-3.8-flash |
| Google GenAI SDK/Pydantic | 2.23.0/2.13.5 |
| 本次擷取 API 嘗試 | 2 次 |
| 呼叫耗時:一般/欄位 | 6.760/7.616 秒 |
| token 總量:一般/欄位 | 4,124/4,146 |
| 圖片 SHA-256 | 47407ae3de5c34f36f77c11f31a98a1537033d70909af923517f5c101b7b86da |
| 離線/SDK 檢查 | 43 項通過(修訂版新增回歸測試至 46 項全數通過)/PASS |
| 原始來源更新時間 | null |
| 人工核對時間 | 2026-09-19T13:06:10.999670+00:00 |
選一張你可使用、字跡清楚的 PNG、JPEG 或 WEBP 海報,第一輪以單一活動或系列場次為主;有明確日期、活動時段和地點,會比較容易核對。自己辦過的講座或工作坊海報也很適合,熟悉內容更容易發現差異。海報裡若有集合或報到資訊,更能看出這次欄位設計的用處。
從 Repo 根目錄操作。沿用 Day 5 已建立的 Python 環境,先檢查本篇程式:
examples/day05/.venv/bin/python examples/day06/verify.py --sdk
這會檢查資料格式、欄位核對、Day 5 搜尋工具的介接,以及 SDK 的設定型別。需要另外建立環境的讀者,可依 examples/day06/requirements.txt 安裝;我們使用的是 google-genai==2.23.0。
接著使用既有 Gemini 金鑰。圖片會送到 Gemini API,先選好適合用於這次實驗的素材,並確認帳戶用量設定。在環境中設定 GEMINI_API_KEY 後執行:
examples/day05/.venv/bin/python examples/day06/run.py --live \
--image /你的海報路徑/poster.jpg \
--source-ref "海報原始網址或來源名稱"
同一張圖片會送出兩次:一次用一般提示,一次用欄位提示。兩組共用模型、schema 與生成設定,差別放在提示文字。
完成後,打開程式印出的 REPORT.html。左側是輸入原圖,右側是兩組整理出的欄位。先在下方選一組作為核對起點,再修正內容、留下心得,按「儲存 review.json」。畫面把模型摘錄的引句也列出來,讓我們回原圖找出它對應的文字。
Gemini 的圖片理解可以同時處理文字提示與圖片。這裡沿用前幾篇的 GenerateContent 呼叫,把圖片的原始位元組放進 Part。[1]
以下節錄 run.py 的核心,image_bytes、prompt 與 client 由完整程式建立:
response = client.models.generate_content(
model="gemini-3.8-flash",
contents=[
types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg"),
prompt,
],
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_json_schema=Extraction.model_json_schema(),
max_output_tokens=8192,
thinking_config=types.ThinkingConfig(
thinking_level=types.ThinkingLevel.LOW
),
),
)
這幾行替我們做兩件事:把圖片交給模型看,也把輸出欄位先約定好。 檔案實際 MIME 類型由程式判讀;上面以 JPEG 為例。
這次的主角是 Gemini 圖片理解與 Structured Outputs。後者把回答整理成指定的 JSON 結構;Python 再用 Pydantic 檢查型別與欄位關係。[2][5] 範例沿用 Day 5 的 SDK 2.23.0 與 GenerateContent 寫法,重現時以本文及該版本的程式為一組。
Day 5 的 Function Calling 是模型提出「幫我查」,程式執行工具;今天的 Structured Outputs 則是請模型「把你讀到的資訊,填進這些欄位」。一個負責提出操作,一個負責整理資料,剛好接成 LOCAL 的兩端。[2]
本次兩組都使用 LOW,並把 max_output_tokens 設為 8,192。這是這次實驗的生成上限,思考與回覆 token 都計入其中;實際用了多少,則看回應的用量紀錄。[3]
今天比較的是提示寫法,所以圖片、模型、schema 與思考設定保持相同。至於提高思考等級是否值得,我會在 Day 20 的模型設定與成本篇,用同一組任務實際比較。
這裡透過 GenerateContent 的 thinking_level 設定思考等級。Gemini 2.5 在這條 API 路徑使用的是 thinking_budget;本例沿用目前模型的設定。[3]
這次我把場次上限留在本機的 Pydantic validator:Gemini 先依欄位結構整理資料,Python 收到後再檢查是否超過十筆。[2][4]
這個改法把「請模型產生哪些欄位」與「應用程式接受多少筆」分開。下面這段 validator 取自修訂版程式(實驗執行的程式版本為 bb0f1f1,程式雜湊與 verification.json 一致):收到十一筆時,本機會拒絕採用,原始回覆仍留下來供檢查。
# events 的十筆上限由收到回覆後的 event_limit 檢查。
# 此欄位未透過 Field(max_length=...) 輸出 maxItems;
# description 提供模型說明,不代表 API 的通用場次上限。
@field_validator("events")
@classmethod
def event_limit(cls, v: list[PosterEvent]) -> list[PosterEvent]:
if len(v) > 10:
raise ValueError("活動場次最多十筆。")
return v
這幾行做的是收到資料後的本機檢查,並把有效資料交回下一步。description 裡的「最多十筆」是提示,真正的數量檢查在這個函式。[4]
我們採用八個欄位:活動名稱、日期、鄉鎮市區、活動時間、活動場地、集合時間、集合地點,以及全程輪椅通行條件。
其中最容易放錯的,是兩組看起來很像的資訊:
| 海報的意思 | 資料欄位 |
|---|---|
| 活動從幾點開始、持續到幾點 | time |
| 參加者幾點要先集合或報到 | meeting_time |
| 活動在哪個場地進行 | venue |
| 參加者先到哪裡碰面 | meeting_point |
假設一場走讀九點半開始,九點先在車站集合,兩個時間都讀對、卻放錯欄位,參加者還是可能遲到。這是欄位設計要處理的問題:除了辨認字,也要看懂標籤與內容的關係。
每個欄位都帶著三樣東西:值、圖片原文、辨讀狀態。文字欄位的結構如下,完整檢查見 schema.py:
class TextField(BaseModel):
value: str | None
quote: str | None
status: Literal["stated", "not_shown", "unclear"]
這幾行讓欄位既能放資料,也能標示「已讀到」「來源缺漏」「待核對」三種情況。以本次第一場花壇大嶺巷步道為例,模型回傳的三個欄位真實值如下:
| 欄位 | value | quote | status | 解讀 |
|---|---|---|---|---|
| 活動日期 | null |
09.19 | unclear | 看見月日,完整日期還缺年份。 |
| 活動時間 | 07:30~11:00 | 週六 07:30~11:00 | stated | 圖片明寫的活動時段。 |
| 全程輪椅通行 | null |
null |
not_shown | 圖片沒有這項資訊。 |
同樣是 null,有的是資訊只寫了一半,有的是根本沒寫。把原文與狀態一起留下來,下一步就知道要補什麼。
quote 幫我找到圖片上的依據;我再回到原圖,確認這段文字說的是活動開始,還是集合報到。這也是為什麼我把 time 和 meeting_time 分成兩個欄位。
海報上的月日保存在 quote,完整日期先保留 null。本篇先按活動名稱查詢;下一篇再用對應的官方公告補年份,把新增依據一起記下來,讓日期搜尋也用得起來。
一般提示很直接:「請閱讀這張活動海報,依提供的欄位結構整理活動資料。」欄位提示則多說明了要怎麼分辨:活動日與報名截止日各有用途;集合資訊獨立填寫;年份缺漏時保留月日原文;通行條件看完整路線的描述。
兩組共用 schema,因此比較的重點是:把欄位的意思說清楚,是否讓模型更容易把資訊放對位置?
這次一般提示那組已取得文字,但在本機的欄位檢查停下來。SCHEMA_ERROR 告訴我們要回頭看哪個欄位;它和請求送出時的 HTTP 400,發生在不同階段。本機驗證在八筆 events 都回報 value_error;對照原始回覆,date.value 填了 "09.19",而 schema.py 的日期規則只接受完整西元日期。欄位提示組則依說明將其值填為 null 並將 "09.19" 保留在 quote 且標示 unclear,順利通過本機驗證。
小型對照的用途,是找到下一次值得改進的問題。每組這次各執行一次,模型使用的條件與用量留在報告中。
我們另外做一個很小的本機反例:建立「活動 09:30、集合 09:00」的測試資料,再刻意把集合時間改成 09:30。
這是 counterexample.py 中手工建立的測試情境。Pydantic 仍能接受這份資料,因為字串、欄位、引句與狀態都完整;但與預期的集合時間一比,就會看見差異。
格式檢查回答「這份資料長得對不對」,來源核對回答「這個值放得對不對」。 Google 的 Structured Outputs 文件也要求應用程式另外檢查值的語意。[2]
另一個值得看的欄位是 accessibility。海報明寫全程適合輪椅,才有依據填 true;明寫路線有不適合通行的條件,才填 false;缺少相關資訊時,留下 null。只看到入口坡道或無障礙廁所,就把整條路線判成可通行,跳得太快了。
這樣,LOCAL 可以先把時間和地點提供給使用者,再針對缺少的通行資訊,建議向主辦確認。
在 REPORT.html 看完原圖,從兩組中選定核對起點、修正必要欄位,留下修改原因與判讀心得,儲存 review.json。接著將核對結果匯出為新的活動目錄:
examples/day05/.venv/bin/python examples/day06/review.py \
--run output/day06/這次的擷取資料夾 \
--review-file /你的下載路徑/review.json
程式會印出 catalog.reviewed.json 的位置。用它進行查詢:
examples/day05/.venv/bin/python examples/day06/query.py \
--catalog output/day06/這次的核對資料夾/catalog.reviewed.json \
--keyword "海報上的活動名稱"
這幾行沿用 Day 5 的 make_search_tool 與搜尋函式,換成今天核對後的資料。這一步直接呼叫 Python 工具,查詢結果存成 query.json;圖片擷取與資料查詢的成果就接起來了。
Day 5 的教學載入器指定手寫合成目錄,所以 Day 6 用自己的入口載入 reviewed_poster_data,再重用搜尋邏輯。這裡還有一個欄位演進:前篇只有一個 time,今天另外加入 meeting_time 與 venue,把活動時段、集合時間和場地拆開。搜尋函式可以保留這些欄位,未來接回 Agent 回答時,也要讓提示明白各欄位的意思。
每筆資料的 source 由程式填入操作者提供的來源,圖片雜湊則指出本次使用哪一份檔案。來源沒有提供更新時間時,updated_at 保留 null;另外記錄的擷取與核對時間,描述的是我們整理資料的時間。
另一種做法,是每次有人問活動,就把整張海報交給模型重看。這次選擇先整理成欄位,再交給工具查詢:查詢端的資料比較一致,人工修正也有固定的位置,不必每次提問都重複消耗圖片辨識的 Token。 代價是多了一道整理與核對流程,主辦單位換海報時,資料也要跟著更新。
這種做法適合來源已經選定、會被重複查詢的活動資料。臨時拿一張圖問一個問題,直接看圖回答也很合理;要不要先整理,取決於資料接下來怎麼用。
今天的範圍收在三件事:圖片從本機提供、核對由人工確認、資料存成可搜尋的 JSON,不把未經審核的抽取結果直接推上線。大量來源、版本衝突與持續更新,接著往 Day 7 展開。
Day 1 提到「模型輸出受約束資料,再由程式驗證」,今天先把這個設計用在海報整理。資料能被查詢之後,後面的 LINE 畫面與服務流程就有了共同依據。
今天我們把活動海報整理成可核對、可查詢的資料。下一篇接住讀者的新問題:主辦換了一張海報,LOCAL 要怎麼找出改動、補上來源,又決定查詢該用哪一版?
你手邊的活動海報,哪一項資訊最容易被放錯欄位:活動時間、集合位置,還是報名截止日?
本篇實驗執行的程式版本為 bb0f1f1,程式雜湊與 verification.json 一致;相關註解與回歸測試已同步更新於 LOCAL 專案 的 examples/day06/,重現說明在 docs/day06/README.md;搜尋工具承接 examples/day05/catalog.py。[6]
前篇:Day 5|ADK+受控查詢:第一個 Agent 與 Orchestration。下一篇:Day 7|看懂不等於可信:來源、版本與不知道。
response_mime_type/response_json_schema 寫法對照這個 SDK 版本。你把 value、quote、status 綁在每個欄位,再用原圖人工核對,讓格式正確和語意正確不會混在一起。若主辦更新同一張海報,你會用圖片雜湊直接建立新版,還是先比對欄位差異,只將變動項目送人工複核?
謝謝,你問到關鍵。兩者會分工:圖片雜湊只辨識「來源檔是否換版」;真正決定人工複核的是 value/quote/status 的欄位差異,沒變的欄位沿用既有核對紀錄。多場次還要先解決跨版本配對,因為目前的 id 綁著雜湊,不能直接拿陣列位置硬比。這一段我會在 Day 7 用同一張海報的兩個版本實際跑一次,敬請期待!